Skip to content

fix: fully stateless OAuth tokens, drop HOMEASSISTANT_TOKEN requirement - #893

Merged
kingpanther13 merged 13 commits into
homeassistant-ai:masterfrom
kingpanther13:fix/oauth-stateless-tokens
Apr 7, 2026
Merged

fix: fully stateless OAuth tokens, drop HOMEASSISTANT_TOKEN requirement#893
kingpanther13 merged 13 commits into
homeassistant-ai:masterfrom
kingpanther13:fix/oauth-stateless-tokens

Conversation

@kingpanther13

Copy link
Copy Markdown
Member

What does this PR do?

Fixes #886 and #837 by making the OAuth provider fully stateless — both access and refresh tokens are now self-contained base64 JSON, eliminating all server-side token storage and disk persistence.

#886 — OAuth still requires HOMEASSISTANT_TOKEN: main_oauth() now sets the OAUTH_MODE_TOKEN sentinel when HOMEASSISTANT_TOKEN is empty/unset, so Settings validation passes without a server-level token. This is a one-line fix in the entrypoint.

#837 — OAuth state lost on container restart: Refresh tokens are now stateless (encoded with ha_token, client_id, scopes, exp, and type), matching the existing stateless access token pattern. This removes _save_state(), _load_state(), _refresh_to_access_map, the state_dir parameter, and oauth_state.json entirely. Tokens survive container restarts by design — clients re-register via DCR automatically and transparently.

Security model unchanged: The LLAT is the authorization boundary. Revoking it in Home Assistant immediately invalidates all derived tokens. Server-side revocation was previously a no-op in practice (in-memory state lost on restart anyway).

Backwards compatibility: load_access_token() accepts tokens with type=access or no type field (old format). Refresh tokens from old format (random strings stored server-side) won't survive a restart — which was already the bug being fixed.

Net result: −226 lines, zero disk I/O, container-restart-safe.

Type of change

  • 🐛 Bug fix

Testing

  • I have tested these changes with a LLM agent
  • All automated tests pass (uv run pytest)
  • Code follows style guidelines (uv run ruff check)

Checklist

  • I have updated documentation if needed

…irement

Fixes homeassistant-ai#886: OAuth mode no longer requires HOMEASSISTANT_TOKEN env var.
When the var is empty/unset, main_oauth() sets the sentinel value so
Settings validation passes.

Fixes homeassistant-ai#837: Both access and refresh tokens are now stateless
(base64-encoded JSON containing the HA LLAT, type, client_id, scopes,
and expiry).  No server-side token state is stored, eliminating
oauth_state.json and all disk I/O.  Tokens survive container restarts
by design — clients re-register via DCR automatically.

Removed: _save_state(), _load_state(), _refresh_to_access_map,
state_dir parameter, get_ha_credentials_for_token().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request refactors the OAuth provider to be fully stateless, significantly improving reliability in containerized environments by eliminating the need for persistent disk storage. By encoding necessary credentials directly into self-contained tokens, the system now handles session management transparently across restarts, while maintaining the existing security model and improving overall maintainability.

Highlights

  • Stateless OAuth Provider: Transitioned the OAuth provider to a fully stateless model where both access and refresh tokens are self-contained base64-encoded JSON, removing the need for server-side storage or disk persistence.
  • Removed Disk Persistence: Eliminated _save_state(), _load_state(), and the oauth_state.json file, ensuring the server survives container restarts without losing sessions.
  • Simplified Configuration: Updated main_oauth() to set a sentinel value for HOMEASSISTANT_TOKEN when unset, allowing the application to run without a server-level token.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request transitions the OAuth provider to a fully stateless architecture, eliminating the need for server-side state persistence and allowing the server to survive restarts without losing sessions. Both access and refresh tokens now carry the Home Assistant Long-Lived Access Token (LLAT) and necessary metadata as base64-encoded JSON. The changes include the removal of disk-based state management, the introduction of a unified token encoding/decoding mechanism, and significant updates to the test suite to reflect the new stateless behavior. Feedback is provided regarding a potential KeyError when recovering tokens and the need for more robust JSON validation during token decoding.

Comment thread src/ha_mcp/auth/provider.py
Comment thread src/ha_mcp/auth/provider.py Outdated
kingpanther13 and others added 2 commits April 6, 2026 07:47
json.loads can return non-dict types (list, str, int) for valid JSON.
Without isinstance check, calling .get() on a non-dict payload would
raise AttributeError, which is not in the except clause.

Addresses Gemini Code Assist review feedback on PR homeassistant-ai#893.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@kingpanther13
kingpanther13 marked this pull request as ready for review April 6, 2026 11:52
@kingpanther13
kingpanther13 requested a review from a team April 6, 2026 11:52
@kingpanther13
kingpanther13 enabled auto-merge (squash) April 6, 2026 11:52

@sergeykad sergeykad left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

9. to_dict docstring says "for storage" but storage is removed (provider.py:55)

This method's docstring reads """Convert to dictionary for storage.""" but the PR removes all disk persistence. The method appears unused outside test_credentials_to_dict. Either remove it or update the docstring to """Convert to dictionary representation.""". Also: the validated_at field (line 53) is set but never read anywhere in the codebase — consider removing it.

Comment thread src/ha_mcp/auth/provider.py
Comment thread src/ha_mcp/auth/provider.py Outdated
Comment thread src/ha_mcp/auth/provider.py
Comment thread src/ha_mcp/__main__.py
Comment thread src/ha_mcp/auth/provider.py
Comment thread src/ha_mcp/auth/provider.py Outdated
Comment thread src/ha_mcp/auth/provider.py Outdated
Comment thread src/ha_mcp/auth/provider.py
Comment thread src/ha_mcp/auth/provider.py Outdated
Comment thread src/ha_mcp/auth/provider.py
kingpanther13 and others added 3 commits April 7, 2026 09:23
1. HMAC-sign refresh tokens to prevent tampering — payload is signed
   with a per-instance server secret, verified on decode. Raw LLAT
   remains in the signed payload (needed for token exchange) but
   the signature prevents modification of any field.
2. Enforce exp on access tokens — encode exp in access tokens and
   reject expired ones in load_access_token.
3. Add logging to revoke_token with RFC 7009 note.
4. Add tests for main_oauth OAUTH_MODE_TOKEN sentinel logic.
5. Add warning logs for security-sensitive token rejections
   (refresh-as-access, client_id mismatch).
6. Use .pop() instead of bare del for ha_credentials cleanup.
7. Update get_ha_credentials docstring for new architecture.
8. Add tests for _decode_token with non-dict JSON payloads.
10. Update _encode_token docstring to describe signing and exp.
11. Add version anchor to backwards-compat comment (v7.x / April 2026).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Addresses sergeykad's follow-up tying items 1-3 together: without
signing access tokens, an attacker who intercepts one can extract
the LLAT and forge arbitrary tokens with any claims.

Now both access and refresh tokens are HMAC-signed using a
per-instance server secret. _decode_token verifies signatures on
all tokens, with backwards-compat fallback for unsigned pre-signing
tokens.

Tokens no longer survive provider restart (new HMAC secret each
startup). Updated tests accordingly: test_tokens_survive_provider_restart
→ test_tokens_invalidated_on_provider_restart, and
test_chained_refresh_across_restart → test_chained_refresh_same_instance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Storage was removed in this PR, so to_dict() and validated_at are
dead code. Removed both and the test_credentials_to_dict test.
Updated class docstring to reflect transient-only purpose.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@kingpanther13

Copy link
Copy Markdown
Member Author

Re: #9 (to_dict/validated_at dead code) — Fixed in ac215d6. Removed to_dict(), validated_at, and the test_credentials_to_dict test. Updated class docstring to: "Temporary HA credentials held between consent form and token exchange."

kingpanther13 and others added 5 commits April 7, 2026 09:58
PR homeassistant-ai#908 added image caching to e2e-tests.yml and pr.yml but missed
performance-tests.yml. Without caching, the workflow hits GHCR rate
limits on every run, causing all performance tests to fail at setup.

Adds the same HA_IMAGE_GHCR env var, actions/cache, and GHCR→Docker
Hub fallback pattern used by the other workflows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comment thread src/ha_mcp/auth/provider.py Outdated
The HMAC secret is regenerated on every restart, so all pre-existing
tokens are already invalidated. The only scenario for an unsigned
token reaching a running server is an attacker crafting a plain
base64 blob to bypass HMAC verification. Remove the path entirely.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@kingpanther13

Copy link
Copy Markdown
Member Author

CI fix: Docker image caching added to performance-tests.yml

Performance tests were failing on all PRs with docker.errors.APIError: 500 Server Error ... toomanyrequests. Root cause: PR #908 added Docker image caching (actions/cache + GHCR→Docker Hub fallback) to e2e-tests.yml and pr.yml, but missed performance-tests.yml. Without caching, every performance test run hits GHCR rate limits trying to pull the HA image cold.

Fixed in 5e2566f — added HA_IMAGE_GHCR env var, actions/cache for /tmp/ha-image.tar, and the same GHCR→Docker Hub fallback pattern used by the other two workflows. This will fix performance tests for all PRs once this merges to master.

Unsigned tokens are now rejected (commit 5e8f4db). Updated test from
asserting acceptance to asserting rejection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@kingpanther13
kingpanther13 merged commit e0fce36 into homeassistant-ai:master Apr 7, 2026
15 checks passed
@github-actions

github-actions Bot commented Apr 7, 2026

Copy link
Copy Markdown
Contributor

🧪 Your changes are now in the dev channel!

Your PR has been merged to master and is available for testing in the dev channel.

Test your changes before the next stable release (biweekly Wednesday):
📖 Dev Channel Documentation

Quick start

# Run dev version
uvx ha-mcp-dev

# Check version
uvx ha-mcp-dev --version

Docker:

docker pull ghcr.io/homeassistant-ai/ha-mcp:dev
docker run --rm -i \
  -e HOMEASSISTANT_URL=http://your-ha:8123 \
  -e HOMEASSISTANT_TOKEN=your_token \
  ghcr.io/homeassistant-ai/ha-mcp:dev

Found an issue? Please open a new bug report and mention this PR for context.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] OAuth still requires HOMEASSISTANT_TOKEN [BUG] OAuth disk persistence does not survive Docker container restarts

2 participants